Skip to main content

Authentication Overview

The Patient Portal Authentication API lets a partner application sign a patient in to the Care Validate Patient Portal using a one-time passcode (OTP) delivered over email or SMS. After verification, the API issues a short-lived access token (JWT) and a longer-lived refresh token that can be rotated without requiring the patient to enter another OTP.

Endpoints

#MethodPathPurpose
1POST/api/v1/users/auth/send-otpRequest a one-time code via Email or SMS
2POST/api/v1/users/auth/verify-otpExchange a valid OTP for an access + refresh token pair
3POST/api/v1/users/auth/refresh-tokenRotate the refresh token and obtain a new access token
4POST/api/v1/users/auth/logoutRevoke the entire refresh-token family

Common Headers

HeaderRequiredDescription
cv-api-keyYes (all endpoints)Tenant API key. Resolves the calling organization. Missing → 400. Unknown / non-partner → 404.
Content-TypeYes (all endpoints)Must be application/json.
AuthorizationNoNone of the four authentication endpoints take a Bearer header. The /refresh-token and /logout endpoints authenticate via the refresh token in the request body.

The organization referenced by cv-api-key must be configured as a partner. Otherwise the response is 404 NOT_FOUND with body { status: 404, success: false, error: 'Organization not found', code: 'NOT_FOUND' }.

Common Response Envelope

All success responses follow:

{ "status": 200, "success": true, "...": "endpoint-specific payload" }

All error responses follow:

{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }

Token-issuing responses (/verify-otp, /refresh-token) additionally set:

Cache-Control: no-store

End-to-End Flow

Token Model

Access token (JWT)

PropertyValue
AlgorithmHS512 (pinned)
Issuerada-backend
Expiry15 minutes (expiresIn: 900)
Required claimtype: "patient-portal" — tokens without it are rejected on Patient Portal endpoints
Cross-tenant guardorganizationId claim is checked against the cv-api-key org on every authenticated call

Payload claims:

{
"userId": "<uuid>",
"organizationId": "<uuid>",
"type": "patient-portal",
"role": "<role>",
"organizationAccessRole": "<access-role>"
}

Refresh token

PropertyValue
FormatOpaque base64url string (32 random bytes)
Storage on serverSHA3-512 base64 hash only — plaintext never persisted
Sliding expiry30 days (resets on each rotation)
Absolute expiry90 days (from family creation)
FamilyEach rotation stays in the same familyId; replay of a rotated token revokes the entire family

Refresh Token Lifecycle

Security Properties

  • No user enumeration. send-otp returns the same response regardless of whether the user exists. verify-otp collapses "no user", "no active OTP", "wrong code", and "already used" into a single 401.
  • OTP brute-force ceiling. OTP is 6 digits (10⁶ keyspace), capped by maxAttempts, with a 5-minute TTL, single-use, and the row is deleted on success.
  • Atomic OTP claim. Verification uses a single conditional UPDATE ... RETURNING to claim an attempt — no read-then-increment race.
  • JWT replay isolation. The type: "patient-portal" claim prevents tokens issued for other surfaces from being used here.
  • Cross-tenant replay isolation. The JWT's organizationId claim is cross-checked against cv-api-key on every call.
  • Algorithm pinned. jwt.verify is called with algorithms: ['HS512'] to prevent algorithm-downgrade attacks.
  • Refresh token storage. Only the SHA3-512 hash is persisted.
  • Refresh rotation. Every successful /refresh-token call invalidates the presented token. Replay of a rotated token revokes the entire family.
  • Family revocation on logout. Logout revokes by familyId — all currently-active siblings are simultaneously revoked.
  • No-store on token responses. Cache-Control: no-store is set on every token-issuing response.

Integrator Guidance

  • cv-api-key is a server-side secret. Treat it like any other API key and keep it on a server when possible.
  • Token storage. Treat both tokens as credentials. Store the access JWT in memory; store the refresh token in platform-secure storage (iOS Keychain, Android Keystore, HttpOnly + Secure + SameSite=Strict cookie on web). Never use localStorage.
  • Proactive rotation. Rotate the refresh token before the 15-minute access-token expiry; do not wait for a 401.
  • REFRESH_REUSED is terminal. Wipe local tokens and require the user to start over with /send-otp. Do not retry.
  • Channel and identifier must agree. channel: "SMS" requires phoneNumber. channel: "EMAIL" requires email.
  • Phone format. E.164 (e.g. +15551234567).
  • send-otp success ≠ user exists. A 200 is returned even when no user matches.
  • Generic verify-otp failures. Do not surface specific OTP-failure reasons to end users beyond what the API returns.
  • Do not reuse tokens across organizations. The organizationId claim is enforced on every call.

Constants Reference

ConstantValue
Access-token TTL900 seconds (15 minutes)
Refresh-token sliding TTL30 days
Refresh-token absolute TTL90 days
OTP digits6
OTP TTL5 minutes
OTP max attempts3
OTP code regex^\d{6}$
OTP hash algorithmSHA3-512 → base64
Refresh-token raw size32 random bytes (base64url)
Refresh-token hash algorithmSHA3-512 → base64
JWT algorithmHS512 (pinned)
JWT issuerada-backend
JWT type claimpatient-portal

Versioning

These endpoints are part of v1 of the Patient Portal API (/api/v1). Breaking changes will be released under a new /api/vN path. Additive, backwards-compatible changes (new optional fields, new error subtypes within existing code taxonomies) may occur within v1 without a path bump.